/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package org.unidata.mdm.draft.service.impl;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.unidata.mdm.draft.context.AbstractDraftDataContext;
import org.unidata.mdm.draft.context.AbstractDraftFieldsContext;
import org.unidata.mdm.draft.context.DraftGetContext;
import org.unidata.mdm.draft.context.DraftPublishContext;
import org.unidata.mdm.draft.context.DraftQueryContext;
import org.unidata.mdm.draft.context.DraftRemoveContext;
import org.unidata.mdm.draft.context.DraftUpsertContext;
import org.unidata.mdm.draft.dao.DraftsDAO;
import org.unidata.mdm.draft.dto.DraftGetResult;
import org.unidata.mdm.draft.dto.DraftProviderInfo;
import org.unidata.mdm.draft.dto.DraftPublishResult;
import org.unidata.mdm.draft.dto.DraftQueryResult;
import org.unidata.mdm.draft.dto.DraftRemoveResult;
import org.unidata.mdm.draft.dto.DraftUpsertResult;
import org.unidata.mdm.draft.exception.DraftExceptionIds;
import org.unidata.mdm.draft.exception.DraftProcessingException;
import org.unidata.mdm.draft.po.DraftPO;
import org.unidata.mdm.draft.po.EditionPO;
import org.unidata.mdm.draft.service.DraftService;
import org.unidata.mdm.draft.type.Draft;
import org.unidata.mdm.draft.type.DraftOperation;
import org.unidata.mdm.draft.type.DraftProvider;
import org.unidata.mdm.draft.type.DraftPublicationOrder;
import org.unidata.mdm.draft.type.Edition;
import org.unidata.mdm.system.exception.PlatformFailureException;
import org.unidata.mdm.system.service.ExecutionService;
import org.unidata.mdm.system.type.variables.Variables;

/**
 * @author Alexander Malyshev
 */
@Service
public class DraftServiceImpl implements DraftService {
    /**
     * This service logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(DraftServiceImpl.class);
    /**
     * Current user name method handle.
     */
    private static final Method CURRENT_USER_NAME_METHOD;
    /**
     * Draft providers.
     */
    private final ConcurrentMap<String, DraftProvider<?>> registry = new ConcurrentHashMap<>();
    /**
     * The drafts DAO.
     */
    private DraftsDAO draftsDAO;
    /**
     * The execution service.
     */
    @Autowired
    private ExecutionService executionService;
    /**
     * SI.
     */
    static {

        Method method = null;
        try {
            Class<?> klass = Class.forName("org.unidata.mdm.core.util.SecurityUtils");
            method = klass.getMethod("getCurrentUserName");
        } catch (ClassNotFoundException | NoSuchMethodException e) {
            throw new PlatformFailureException(
                    "Reflection failure [org.unidata.mdm.core.util.SecurityUtils.getCurrentUserName].",
                    e, DraftExceptionIds.EX_DRAFT_CURRENT_USER_NAME_METHOD);
        }

        CURRENT_USER_NAME_METHOD = method;
    }
    /**
     * Constructor.
     * @param draftsDAO the drats DAO
     */
    @Autowired
    public DraftServiceImpl(DraftsDAO draftsDAO) {
        super();
        this.draftsDAO = draftsDAO;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void register(DraftProvider<?> p) {
        Objects.requireNonNull(p, "Draft provider instance must not be null.");
        Objects.requireNonNull(p.getId(), "Draft provider ID must not be null.");
        registry.put(p.getId(), p);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public List<DraftProviderInfo> providers() {
        return registry.values().stream()
                .map(p -> {

                    DraftProviderInfo dpi = new DraftProviderInfo();
                    dpi.setId(p.getId());
                    dpi.setDescription(p.getDescription());
                    dpi.setTags(p.getTags());

                    return dpi;
                })
                .collect(Collectors.toList());
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public DraftGetResult get(final DraftGetContext ctx) {

        if (Objects.isNull(ctx)) {
            return null;
        }

        // 1. Fail GET op, if id is not set
        ensureDraftIdSet(ctx);

        Draft d = convert(draftsDAO.loadDraft(ctx.getDraftId()));

        // 2. Standard checks
        ensureDraftFound(d, ctx.getDraftId());
        ensureProviderExists(d.getProvider());

        DraftProvider<?> provider = registry.get(d.getProvider());

        ctx.setStartTypeId(provider.getPipelineId(DraftOperation.GET_DATA));
        ctx.currentDraft(d);

        // 3. Run pipeline
        DraftGetResult result = executionService.execute(ctx);
        if (Objects.nonNull(result) && !result.hasDraft()) {
            result.setDraft(d);
        }

        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional
    public DraftUpsertResult upsert(final DraftUpsertContext ctx) {

        if (Objects.isNull(ctx)) {
            return null;
        }

        // 1. Draft object
        Draft d = draft(ctx);

        // 2. Data
        DraftProvider<?> provider = registry.get(d.getProvider());

        ctx.setStartTypeId(provider.getPipelineId(DraftOperation.UPSERT_DATA));
        ctx.currentDraft(d);

        DraftUpsertResult result = executionService.execute(ctx);
        if (Objects.isNull(result) || !result.isSuccess()) {
            return result;
        }

        // 3. Save new draft object
        if (!d.isExisting()) {

            long draftId = draftsDAO.putDraft(convert(d));
            d = Draft.reset(d, draftId);
        }

        // 4. Save payload
        if (result.hasEdition()) {

            Edition e = result.getEdition();

            if (Objects.isNull(e.getCreatedBy())) {
                e.setCreatedBy(getCurrentUserName());
            }

            e.setDraftId(d.getDraftId());
            e.setRevision(draftsDAO.putEdition(convert(e, provider)));
        }

        // 5. Possibly update description and/or variables
        update(d, ctx, result);

        // 6. Reset draft for possibly updated id
        result.setDraft(d);
        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional
    public DraftRemoveResult remove(final DraftRemoveContext ctx) {

        if (Objects.isNull(ctx)) {
            return null;
        }

        return new DraftRemoveResult(Objects.nonNull(ctx.getDraftId())
                ? draftsDAO.wipeDraft(ctx.getDraftId())
                : draftsDAO.wipeDrafts(ctx.getParentDraftId(), ctx.getProvider(), ctx.getSubjectId(), ctx.getOwner(), ctx.getTagsAsArray()));
    }
    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional
    public DraftPublishResult publish(final DraftPublishContext ctx) {

        if (Objects.isNull(ctx)) {
            return null;
        }

        // 1. Fail PUBLISH op, if id is not set
        ensureDraftIdSet(ctx);

        Draft d = convert(draftsDAO.loadDraft(ctx.getDraftId()));

        // 2. Standard checks
        ensureDraftFound(d, ctx.getDraftId());
        ensureProviderExists(d.getProvider());

        // 3. Publish children recursively, if CHILDREN_FIRST is specified
        DraftPublicationOrder publicationMode = d.getPublicationOrder();
        if (publicationMode == DraftPublicationOrder.CHILDREN_FIRST) {
            cascade(d, ctx);
        }

        // 4. Run pipeline
        DraftProvider<?> provider = registry.get(d.getProvider());

        ctx.setStartTypeId(provider.getPipelineId(DraftOperation.PUBLISH_DATA));
        ctx.currentDraft(d);

        DraftPublishResult result = executionService.execute(ctx);
        if (Objects.nonNull(result) && !result.hasDraft()) {
            result.setDraft(d);
        }

        // 5. Publish children recursively, if CHILDREN_LAST is specified
        boolean nonStop = Objects.isNull(result) || !result.isStop();
        if (publicationMode == DraftPublicationOrder.CHILDREN_LAST && nonStop) {
            cascade(d, ctx);
        }

        // 6. Delete draft, if requested or update subject for new items
        if (Objects.nonNull(result) && result.isSuccess()) {

            if (ctx.isDelete()) {
                draftsDAO.wipeDraft(d.getDraftId());
            } else if (StringUtils.isBlank(d.getSubjectId())
                    && StringUtils.isNotBlank(result.getSubjectId())) {
                draftsDAO.putSubject(d.getDraftId(), result.getSubjectId());
                result.getDraft().setSubjectId(result.getSubjectId());
            }
        }

        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public boolean hasDraft(final DraftQueryContext ctx) {

        if (Objects.isNull(ctx)) {
            return false;
        }

        return this.count(ctx) > 0;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public long count(DraftQueryContext ctx) {

        if (Objects.isNull(ctx)) {
            return 0L;
        }

        return draftsDAO.countDrafts(ctx.getParentDraftId(), ctx.getProvider(), ctx.getSubjectId(), ctx.getOwner(), ctx.getTagsAsArray());
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public DraftQueryResult drafts(final DraftQueryContext ctx) {

        if (Objects.isNull(ctx)) {
            return null;
        }

        Stream<DraftPO> ds = Objects.nonNull(ctx.getDraftId())
                ? Stream.ofNullable(draftsDAO.loadDraft(ctx.getDraftId()))
                : draftsDAO.loadDrafts(
                        ctx.getParentDraftId(),
                        ctx.getProvider(),
                        ctx.getSubjectId(),
                        ctx.getOwner(),
                        ctx.getTagsAsArray(),
                        ctx.getLimit(),
                        ctx.getStart()).stream();

        return new DraftQueryResult(ds
                .filter(Objects::nonNull)
                .map(this::convert)
                .map(d -> inject(ctx, d))
                .collect(Collectors.toList()));
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public List<Edition> editions(long draftId, boolean withData) {
        return draftsDAO.loadEditions(draftId, withData).stream()
                .map(this::convert)
                .collect(Collectors.toList());
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public Edition current(long draftId, boolean withData) {
        EditionPO po = draftsDAO.loadCurrentEdition(draftId, withData);
        return convert(po);
    }

    private void cascade(Draft d, DraftPublishContext ctx) {

        // Publish children recursively
        List<DraftPO> children = draftsDAO.loadDrafts(d.getDraftId(), null, null, null, null, null, null);
        for (DraftPO child : children) {

            // We preserve drafts tree until the end.
            // If the root parent draft is set to be wiped,
            // all its children are wiped too by the constraint.
            publish(DraftPublishContext.builder()
                .draftId(child.getId())
                .parentDraftId(d.getDraftId())
                .operationId(ctx.getOperationId())
                .delete(false)
                .force(ctx.isForce())
                .build());
        }
    }

    private void update(Draft d, DraftUpsertContext ctx, DraftUpsertResult result) {

        final Variables variables = result.hasVariables() ? result.getVariables() : null;
        final String subjectId = result.hasSubjectId() ? result.getSubjectId() : null;
        final Set<String> tags = result.hasTags() ? result.getTags() : null;
        final DraftPublicationOrder order = Objects.nonNull(ctx.getPublicationOrder()) && ctx.getPublicationOrder() != d.getPublicationOrder()
                ? ctx.getPublicationOrder()
                : null;
        final String description = Objects.nonNull(ctx.getDescription()) && !StringUtils.equals(d.getDescription(), ctx.getDescription())
                ? ctx.getDescription()
                : null;

        boolean needsUpdate =
                   Objects.nonNull(variables)
                || Objects.nonNull(subjectId)
                || Objects.nonNull(description)
                || Objects.nonNull(tags)
                || Objects.nonNull(order);

        if (needsUpdate) {

            DraftPO po = new DraftPO();
            po.setId(d.getDraftId());
            po.setDescription(description);
            po.setSubject(subjectId);
            po.setVariables(variables);
            po.setPublicationOrder(order);

            if (CollectionUtils.isNotEmpty(tags)) {
                po.setTags(tags.toArray(String[]::new));
            }

            draftsDAO.putDraftProperties(po);

            refresh(d, po);
        }
    }

    private void refresh(Draft d, DraftPO properties) {

        if (d.isExisting()) {

            if (Objects.nonNull(properties.getVariables())) {
                d.setVariables(properties.getVariables());
            }

            if (Objects.nonNull(properties.getSubject())) {
                d.setSubjectId(properties.getSubject());
            }

            if (Objects.nonNull(properties.getDescription())) {
                d.setDescription(properties.getDescription());
            }

            if (Objects.nonNull(properties.getTags())) {
                d.setTags(Arrays.asList(properties.getTags()));
            }

            if (Objects.nonNull(properties.getPublicationOrder())) {
                d.setPublicationOrder(properties.getPublicationOrder());
            }
        }
    }

    private Draft draft(final DraftUpsertContext ctx) {

        Draft d = null;

        // 1.1 Existing
        if (Objects.nonNull(ctx.getDraftId())) {

            d = convert(draftsDAO.loadDraft(ctx.getDraftId()));

            // 1.1.1 Standard checks
            ensureDraftFound(d, ctx.getDraftId());
            ensureProviderExists(d.getProvider());

        // 1.2 New
        } else {

            // 1.2.1 Verify create
            ensureProviderSet(ctx);
            ensureProviderExists(ctx.getProvider());

            String currentUser = getCurrentUserName();

            d = new Draft(0L);
            d.setSubjectId(ctx.getSubjectId());
            d.setProvider(ctx.getProvider());
            d.setDescription(ctx.getDescription());
            d.setOwner(Objects.nonNull(ctx.getOwner()) ? ctx.getOwner() : currentUser);
            d.setParentDraftId(ctx.getParentDraftId());
            d.setTags(ctx.getTags());
            d.setCreateDate(new Date(System.currentTimeMillis()));
            d.setCreatedBy(currentUser);
            d.setPublicationOrder(ctx.getPublicationOrder());
        }

        return d;
    }

    private Draft convert(DraftPO po) {

        if (Objects.isNull(po)) {
            return null;
        }

        Draft result = new Draft(po.getId());

        result.setParentDraftId(po.getParentId());
        result.setSubjectId(po.getSubject());
        result.setProvider(po.getProvider());
        result.setOwner(po.getOwner());
        result.setDescription(po.getDescription());
        result.setEditionsCount(po.getEditionsCount());
        result.setVariables(po.getVariables());
        result.setTags(ArrayUtils.isNotEmpty(po.getTags()) ? Arrays.asList(po.getTags()) : Collections.emptyList());
        result.setCreateDate(po.getCreateDate());
        result.setCreatedBy(po.getCreatedBy());
        result.setUpdateDate(po.getUpdateDate());
        result.setUpdatedBy(po.getUpdatedBy());
        result.setPublicationOrder(po.getPublicationOrder());

        return result;
    }

    private DraftPO convert(Draft draft) {

        if (Objects.isNull(draft)) {
            return null;
        }

        DraftPO result = new DraftPO();

        result.setId(draft.getDraftId());
        result.setSubject(draft.getSubjectId());
        result.setParentId(draft.getParentDraftId());
        result.setProvider(draft.getProvider());
        result.setCreateDate(draft.getCreateDate());
        result.setCreatedBy(draft.getCreatedBy());
        result.setDescription(draft.getDescription());
        result.setOwner(draft.getOwner());
        result.setTags(CollectionUtils.isEmpty(draft.getTags()) ? null : draft.getTags().toArray(String[]::new));
        result.setUpdateDate(draft.getUpdateDate());
        result.setUpdatedBy(draft.getUpdatedBy());
        result.setPublicationOrder(draft.getPublicationOrder());

        return result;
    }

    private Edition convert(EditionPO po) {

        if (Objects.isNull(po)) {
            return null;
        }

        Edition result = new Edition();

        result.setCreateDate(po.getCreateDate());
        result.setCreatedBy(po.getCreatedBy());
        result.setDraftId(po.getDraftId());
        result.setRevision(po.getRevision());

        if (ArrayUtils.isNotEmpty(po.getContent())) {

            DraftProvider<?> p = registry.get(po.getProvider());
            result.setContent(p.fromBytes(po.getContent()));
        }

        return result;
    }

    private EditionPO convert(Edition edition, DraftProvider<?> provider) {

        if (Objects.isNull(edition)) {
            return null;
        }

        EditionPO result = new EditionPO();

        result.setCreateDate(edition.getCreateDate());
        result.setCreatedBy(edition.getCreatedBy());
        result.setDraftId(edition.getDraftId());

        if (Objects.nonNull(edition.getContent())) {
            result.setContent(provider.toBytes(edition.getContent()));
        }

        return result;
    }

    private Draft inject(DraftQueryContext ctx, Draft d) {

        if (ctx.withEditions()) {
            d.setEditions(editions(d.getDraftId(), ctx.withData()));
        }

        return d;
    }

    private void ensureDraftFound(Draft d, long id) {
        if (Objects.isNull(d)) {
            final String message = "Draft object not found by id [{}].";
            LOGGER.warn(message, id);
            throw new DraftProcessingException(message, DraftExceptionIds.EX_DRAFT_NOT_FOUND_BY_ID, id);
        }
    }

    private void ensureProviderExists(String id) {
        if (!registry.containsKey(id)) {
            final String message = "Draft provider not found by id [{}].";
            LOGGER.warn(message, id);
            throw new DraftProcessingException(message, DraftExceptionIds.EX_DRAFT_PROVIDER_NOT_FOUND_BY_ID, id);
        }
    }

    private void ensureProviderSet(AbstractDraftFieldsContext ctx) {
        if (Objects.isNull(ctx.getProvider())) {
            throw new DraftProcessingException("Provider ID must not be null.", DraftExceptionIds.EX_DRAFT_EMPTY_PROVIDER);
        }
    }

    private void ensureDraftIdSet(AbstractDraftDataContext ctx) {
        if (Objects.isNull(ctx.getDraftId()) || ctx.getDraftId() <= 0) {
            throw new DraftProcessingException("Draft ID must not be null and must be a valid positive integer.", DraftExceptionIds.EX_DRAFT_EMPTY_DRAFT_ID);
        }
    }

    private String getCurrentUserName() {

        String current = null;
        if (CURRENT_USER_NAME_METHOD != null) {
            try {
                current = (String) CURRENT_USER_NAME_METHOD.invoke(null, ArrayUtils.EMPTY_OBJECT_ARRAY);
            } catch (IllegalAccessException | InvocationTargetException | IllegalArgumentException e) {
                LOGGER.warn("Reflection failure.", e);
            }
        }

        return current;
    }
}
